Repository navigation
Conversation
brendon
added a commit
that referenced
this pull request
Nov 5, 2026
A default scope could preload attachments (or anything else) on every lock, and since #86 locks twice that cost doubled. It could also hide the record we meant to lock, so no lock was taken at all. Associated records are now locked by primary key with only their ids selected. Scopes without an association still lock whole rows: selecting only ids lets MySQL lock through a covering index on the position column, and a transaction queued there blocks our own writes into the gap before it, which deadlocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lock used to load the associated record, through any default scope, so that it could lock it. That reads before the lock, which on MySQL fixes the transaction's snapshot. Default scopes also added their preloads to every lock, and could hide the record so that no lock was taken at all. Associated records are now locked by primary key using the foreign keys in memory, selecting only ids. Scopes without an association still lock whole rows: selecting only ids lets MySQL lock through a covering index on the position column, and a transaction queued there blocks our own writes into the gap before it, which deadlocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BREAKING CHANGE Under MySQL's default REPEATABLE READ, a transaction's first plain read fixes the snapshot that its later reads see. Anything that reads before the scope lock (a validation, a callback, nested attributes) hides changes committed while we waited for the lock, which leads to duplicate positions, unique index violations or misplaced records. A save that begins its own transaction now runs at READ COMMITTED. This also covers reads during attribute assignment in update, which no lock taken by save can get ahead of, because update opens its transaction first. An open transaction's level can't be changed, so a save that joins one locks as soon as it begins, before anything else in the save can read. This includes association.create and <<, as Rails opens a transaction around them. Apps can opt out with positioning_isolation = nil, on ActiveRecord::Base or on a model. A level chosen with ActiveRecord.with_transaction_isolation_level takes precedence. PostgreSQL and SQLite are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brendon
force-pushed
the
fix/lock-before-reading
branch
from
November 5, 2026 22:49
6867606 to
b51cd77
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For 0.5.0, as this contains a breaking change. Replaces #72.
Problem
Under MySQL's default REPEATABLE READ, a transaction's first plain read fixes the snapshot that all of its later plain reads see. Locking reads and
UPDATEs see the latest data, butMAX(position),position_wasand the relative position lookups don't.So anything that reads before the scope lock hides changes committed while we waited for it: the gem loading the
belongs_toassociation in order to lock it, a validation, a callback, or nested attributes. The result is duplicate positions,RecordNotUniquewhere there's a unique index, or a new record placed in the wrong spot. PostgreSQL's default READ COMMITTED takes a fresh snapshot for each statement, so it isn't affected.Change
There are two commits.
Lock scopes by key. The lock no longer loads the associated record first. It locks by primary key using the foreign keys in memory, selecting only ids and ignoring default scopes. This covers polymorphic and composite keys. Default scopes can no longer add preloads to a lock, or hide the record so that no lock is taken. Scopes without an association still lock whole rows: selecting only ids lets MySQL lock through a covering index on the position column, and a transaction queued there blocks our own writes into the gap before it, which deadlocks.
Run positioned saves at READ COMMITTED on MySQL.
positionedincludes a small module that handles the two ways a save can start:with_transaction_returning_status, whichsave,save!,destroy,destroy!,touch,updateandupdate!all go through. The override opens the real transaction, so it re-raisesActiveRecord::Rollbackfor the one that joining swallows.association.createand<<.Both are needed. Neither alone handles every row below.
Breaking changes
On MySQL only. PostgreSQL and SQLite are unchanged.
updateopens its transaction before we know what's changing. That includes its callbacks and any records it autosaves.FOR UPDATEno longer stops inserts into the gaps between rows in those saves. This is the risky one, as code relying on gap locks loses that protection with no error.ActiveRecord.with_transaction_isolation_level, which takes precedence.binlog_formatto beROW(the default) orMIXED. This wasn't tested.To opt out:
Results by call pattern
MySQL on Rails 8.1. Each case has a validation that reads, with a concurrent create at position 1 straight after the first query. The first column is the last release.
item.save,item.updateRecordNotUniqueitem.update, where assignment readsRecordNotUniquelist.items.create,list.items << itemItem.transaction { item.update }RecordNotUniqueList.transaction { item.update },ActiveRecord::Base.transaction { item.update }RecordNotUniqueItem.transaction { read; item.update },List.transaction { read; item.update }RecordNotUniqueRecordNotUniquelist.save, autosaving a new itemNot covered
The last two rows. A snapshot taken before the save begins can't be undone: MySQL rejects
SET TRANSACTIONonce a transaction is in progress, and theSESSIONform doesn't affect the running one. The README says to open the transaction withtransaction(isolation: :read_committed), and there's a test for that advice.Alternative considered
Overriding
transactionon positioned classes as well would also fixItem.transaction { read; item.update }. It would silently change the level of everyItem.transactionblock in an app, including multi-model ones, and without the early lock it wouldn't helpList.transaction { item.update }. I left it out.Testing
Query counts in that app, which has default scopes that preload attachments and rich text:
These exclude
BEGIN,COMMITandSET TRANSACTION, so a save at READ COMMITTED has one extra round trip on top.Not tested: MariaDB, Trilogy, and proxies such as ProxySQL or Vitess, which need
SET TRANSACTIONandBEGINto reach the same backend connection.🤖 Generated with Claude Code